Skip to content

feat(generators): add --normalize-prefixes for conventional prefix names in every generator - #4

Open
jdsika wants to merge 1 commit into
mainfrom
fix/normalize-well-known-prefixes
Open

jdsika wants to merge 1 commit into
mainfrom
fix/normalize-well-known-prefixes

Conversation

@jdsika

@jdsika jdsika commented Mar 26, 2026 •

Copy link
Copy Markdown

Mirror of linkml#3446, same head. Review there; this PR tracks the fork stack.

Proposed upstream description (2026-10-30), not yet applied to linkml#3446; it is copied there after review.

Merge order of this series: linkml#3716 → linkml#4168 → linkml#3993 → linkml#4144 → linkml#4171 → linkml#3989 → linkml#3990 → linkml#4156 → linkml#4157 → linkml#4158 → linkml#4028 → linkml#4169 → linkml#4170 → linkml#3446. A stack is rebased when its base lands.

Read first: generators/common/prefixes.py (308 lines, the plan); the eight generators only wire it in (170 lines); the rest is tests, snapshots and docs.

Adds an opt-in --normalize-prefixes option that writes the conventional name of a well-known namespace, the same in every generator that prints prefix names. Only names change; every IRI stays the same.

Example

prefixes:
  dce: http://purl.org/dc/elements/1.1/
  w3prov: http://www.w3.org/ns/prov#
  ex: http://example.org/
imports: [linkml:types]
default --normalize-prefixes
gen-owl, gen-shacl, gen-shex, gen-sparql, gen-yarrrml, gen-sssom dce:title, w3prov:Agent dc:title, prov:Agent
gen-jsonld-context defines dce, w3prov defines dce, w3prov and dc, prov; writes dc:title
ex (no conventional name) ex ex

Why the two groups differ

  • Self-contained documents (Turtle, RDF/XML, ShExC, SPARQL, YARRRML, SSSOM): a prefix only abbreviates IRIs in that document; prefixes "are not a formal part of the RDF data model" (RDF 1.1 Concepts §1.4, Turtle 1.1 §2.4). They use the conventional name only.
  • A JSON-LD context is shared with the data written against it. A compact IRI whose prefix the context no longer defines "is interpreted as IRI instead" (JSON-LD 1.1 §4.1.5), so "source": "dce:x" would stop expanding. It keeps every declared name and adds the conventional one; data may use either.

Where the names come from

  • Two curated sources, no hand-written table: prefixmaps' linked_data context and the names rdflib binds by default (rdflib serializes the RDF outputs, so its names win; the two agree on every namespace both name). This goes through prefixmaps, as asked in review.
  • prefixmaps' merged context is not used: it names a namespace after the first registry that lists it (alice for http://example.org/ from prefix.cc, ADMS from bioregistry). The earlier git pin on prefixmaps is gone; released prefixmaps (≥ 0.2.2) works.

One plan per schema

linkml.generators.common.prefixes.preferred_prefixes(sv) returns a curies Converter: the preferred name per namespace, the declared names as synonyms. Every generator applies the same plan. A prefix keeps its declared name, with a warning, when the conventional name

  • is declared for another namespace by the schema or an import: LinkML expands data CURIEs with all of them, so linkml:types' schema (for http://schema.org/) stays http://schema.org/'s, and a schema's sdo for https://schema.org/ keeps its name;
  • is wanted by two namespaces, or
  • is the name of a class, slot or permissible value, which a JSON-LD context uses as a term (a slot time blocks owltime → time).

Per generator

  • gen-owl, gen-shacl, gen-shex (ShExC and RDF): bind the planned names in rdflib; a declared schema: http://schema.org/ also wins over rdflib's default schema → https, so no schema1.
  • gen-sparql, gen-yarrrml, gen-sssom: rename the prefixes the document declares, and the CURIEs using them (SSSOM: the CURIE columns only); never to a name the document writes undeclared (skos:exactMatch in SSSOM).
  • gen-jsonld-context: adds the planned names, rewrites compact IRIs in values (not keys, which data uses) and the frame @type; --no-prefixes renames nothing, since its prefixes then come from another context.
  • Not offered: gen-prefix-map (a prefix map gives each namespace one name, the schema's; curies rejects synonyms there), gen-rdf and gen-jsonld (CURIEs are literal data), gen-python, gen-pydantic, gen-namespaces (prefix names are part of the generated code). To change the names users write, rename the prefixes in the schema.

Testing

test_normalize_prefixes.py (527 tests) over 15 schemas with hard cases (both schema.org variants, imported names in use or unused, dc/dce, case-only alias, a slot or enum value named like a prefix, alias as default_prefix, in a type uri or enum meaning, prefixes declared only in an import, a declared schema: http://schema.org/):

  • documents mean the same with and without the option: rdflib isomorphism (ttl, xml, n3, json-ld, nt; --diff-stable, mapped URIs, metaclasses, metadata profile, annotations), ShExJ, SPARQL parse trees with expanded names, YARRRML and SSSOM with expanded CURIEs; and they use the planned names;
  • context: every term expands the same (--use-curies, --flatprefixes, --fix-multivalue-containers, --no-mergeimports); data using any prefix of the schema or its imports expands the same, or, for an imported prefix only the normalized context defines, to the namespace LinkML uses; data written with the planned names means the same; --no-model + --no-prefixes contexts, gen-prefix-map + --no-prefixes, an importer with --exclude-imports next to its import's context, frames, YAML text input with local imports;
  • CLI: offered on the seven generators, --no-normalize-prefixes equals the default, rejected elsewhere.

Ten mutations of the plan and the generators (closure prefixes, ownership, blocked names, kept aliases, replace=True, keys, SSSOM columns, the undeclared-name guard, YAML text) each fail tests. tests/linkml, the slow suite and tests/linkml_runtime pass.

test_csv_limit now restores csv.field_size_limit: left at 20, it broke prefixmaps' CSV loading in later tests on the same xdist worker.

Limitations

  • JSON-LD compaction picks the shortest name (JSON-LD 1.1 API, IRI Compaction), so data compacted with the context may still use the declared one.
  • Data that uses a newly added name as a bare enum value or JSON key would read it as the prefix. Valid data cannot (those names are blocked).
  • gen-yarrrml and gen-sssom don't declare imported prefixes, with or without the option; their CURIEs with such prefixes stay undeclared and unrenamed.

Checklist

  • My code follows the contributor guidelines
  • I have added tests that prove my fix/feature works
  • Existing tests pass locally with my changes

@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch 3 times, most recently from 42e007b to 71db97f Compare March 26, 2026 17:16
@jdsika jdsika changed the title fix(generators): normalize well-known prefixes in output fix(generators): add --normalize-prefixes flag for well-known prefix names Mar 26, 2026
@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch 8 times, most recently from 60f62c4 to 1b36dc0 Compare April 2, 2026 09:38
@jdsika

jdsika commented Apr 2, 2026

Copy link
Copy Markdown
Author

🔍 Adversarial Review — PR #4

Summary

Solid feature with thorough test coverage, but contains a confirmed race condition between Phase 1 and Phase 2 of normalize_graph_prefixes() that can silently swap namespace URIs (HTTPS → HTTP) for schema.org. The cross-PR conflict with PR #1 (well_known_prefix_map() defined differently in both) also needs a merge strategy.

🐛 Bugs & Issues

1. Phase 2 overwrites Phase 1 normalization for HTTP/HTTPS variants (Confirmed)

When a schema declares sdo: https://schema.org/ and the metamodel runtime injects schema: http://schema.org/, the following sequence occurs:

  • Phase 1 correctly rebinds sdo → schema: https://schema.org/ (user's HTTPS)
  • After Phase 1, the graph still contains schema1: http://schema.org/ (orphaned runtime HTTP binding)
  • Phase 2 sees schema1: http://schema.org/, looks up wk["http://schema.org/"] = "schema", checks collision ("schema" not in schema_prefixes — user declared sdo, not schema), and rebinds schema: http://schema.org/ with override=True, replace=True
  • This silently replaces the Phase 1 HTTPS binding with the HTTP variant

Reproduced locally with rdflib 7.x:

from rdflib import Graph, Namespace, URIRef
g = Graph()
g.bind('schema', Namespace('http://schema.org/'))      # metamodel
g.namespace_manager.bind('sdo', URIRef('https://schema.org/'))  # user

# Phase 1
g.bind('schema', Namespace('https://schema.org/'), override=True, replace=True)
# → schema: https://schema.org/ ✅, schema1: http://schema.org/ (orphan)

# Phase 2
g.bind('schema', Namespace('http://schema.org/'), override=True, replace=True)
# → schema: http://schema.org/ ❌  (HTTPS binding silently lost!)

Fix: Phase 2 should check whether std_pfx is already bound in the graph (not just schema_prefixes) before rebinding:

# In Phase 2, before rebinding:
current_ns_for_std = dict(graph.namespaces()).get(std_pfx)
if current_ns_for_std and str(current_ns_for_std) != ns_str:
    continue  # std_pfx already claimed by Phase 1 for a different URI

2. test_sdo_normalised_to_schema may be passing for the wrong reason

The test asserts pfx["schema"] == "https://schema.org/", but if Phase 2 overwrites this with HTTP, the test should fail. If it passes, it may be because the metamodel's emit_prefixes don't include schema for the test schema, meaning the HTTP binding is never injected — hiding the bug in production schemas that DO trigger both bindings. The test should explicitly set up both HTTP and HTTPS bindings to exercise Phase 2 properly.

⚠️ Concerns

3. Cross-PR conflict with PR #1 — well_known_prefix_map() defined differently

PR #1 PR #4
Approach Dynamic: {str(ns): str(pfx) for pfx, ns in RdfGraph().namespaces()} Static: MappingProxyType({...}) with ~30 entries
rdflib dependency Changes with rdflib version Frozen, version-independent
HTTP/HTTPS variants Only what rdflib provides Explicitly includes both

PR #4's static approach is explicitly designed to solve a problem with PR #1's dynamic approach (rdflib defaults changed between 6.x and 7.x). These will conflict on merge. Recommendation: merge PR #4 first, or rebase PR #1 to use PR #4's static map.

4. Cross-PR conflict — flag forwarding in jsonldgen.py

PR #4 forwards both normalize_prefixes AND deterministic to the embedded ContextGenerator:

for flag in ("normalize_prefixes", "deterministic"):
    if hasattr(self, flag):
        context_kwargs.setdefault(flag, getattr(self, flag))

PR #1 adds deterministic handling in a different location (post-serialization in end_schema). PR #4's forwarding of deterministic creates a dependency on PR #1's field existing. The hasattr guard prevents a crash, but semantically PR #4 shouldn't forward a flag it doesn't define. Either:

5. PR title says fix: but commit says feat: — which is it?

The commit message uses feat(generators): while the PR title uses fix(generators):. This is a new opt-in flag, which is a feature — feat: is correct per conventional commits.

6. Static map maintenance burden

The _WELL_KNOWN_PREFIX_MAP needs manual updates when rdflib adds new curated defaults. The test_matches_rdflib_defaults test catches additions but not removals. Consider adding a reverse check: entries in the static map that are NOT in rdflib's defaults could indicate stale entries.

🧪 Test Coverage Assessment

Strong coverage (18 tests across 7 classes):

  • ✅ OWL, SHACL, JSON-LD context normalization (sdo→schema, dce→dc)
  • ✅ HTTP/HTTPS schema.org variants
  • ✅ Collision detection with warnings (caplog)
  • ✅ Cross-generator consistency
  • ✅ Static map integrity and copy safety
  • ✅ JSONLDGenerator flag forwarding
  • ✅ Flag-off backward compatibility

Gaps:

  • ❌ No test for normalize_prefixes + exclude_imports interaction
  • ❌ No test for empty schema (zero prefixes) — is _prefix_remap.clear() safe?
  • ❌ No test that explicitly sets up both HTTP and HTTPS runtime bindings to exercise Phase 2 overwrite (the scenario from Bug feat(generators): add --deterministic flag with hybrid RDFC-1.0 + rdflib serialization #1)
  • ❌ No test for add_prefix being called before __post_init__ completes (defensive, but _prefix_remap access would crash)
  • ❌ CURIE remapping test (test_normalize_prefixes_curie_remapping) only checks that Person @id starts with schema: — doesn't verify full_name slot's @id uses schema:name instead of sdo:name

📋 Fix Plan

  1. Fix Phase 2 overwrite bug — add a graph-binding check before rebinding in Phase 2 to prevent overwriting Phase 1's normalization
  2. Add regression test — explicitly set up a graph with both schema: http://schema.org/ and sdo: https://schema.org/, run normalize, and assert the HTTPS binding survives
  3. Remove "deterministic" from forwarding loop — or gate it behind hasattr only (already done) but add a comment explaining the cross-PR dependency
  4. Fix PR title — change fix: to feat: for consistency with the commit
  5. Add empty-schema test — verify no crash when schema has zero prefixes
  6. Add exclude_imports interaction test — verify normalization works when imports are excluded
  7. Strengthen CURIE remapping test — assert that slot URIs (not just class URIs) are remapped in the context

✅ What's Good

  • Excellent documentation: the 3-case handling in visit_schema is well-commented with case numbers
  • Defensive _prefix_remap initialization: correctly placed before super().__post_init__() with clear comment explaining why
  • Collision detection: warning on skip is the right behavior — no silent data loss
  • MappingProxyType for the static map: prevents accidental mutation
  • well_known_prefix_map() returns a copy: callers can't corrupt the canonical map
  • Phase 2 concept is sound: cleaning up runtime-injected orphan bindings is the right idea, it just needs the overwrite guard
  • Cross-generator consistency test: ensures OWL, SHACL, and JSON-LD agree — excellent
  • Backward compatibility: flag defaults to off, existing behavior preserved

@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch from d9fb738 to dd0ce0c Compare April 2, 2026 15:24
@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch 3 times, most recently from 8d098e9 to ef47b47 Compare April 24, 2026 20:26
@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch 11 times, most recently from 8dc3ece to 260a97e Compare May 11, 2026 15:49
@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch from 260a97e to e56ca65 Compare May 11, 2026 16:15
@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch from e56ca65 to 5f17e19 Compare June 9, 2026 15:21
@rmessaou
rmessaou force-pushed the fix/normalize-well-known-prefixes branch 2 times, most recently from ce55ccb to 909eb7d Compare July 8, 2026 08:49
@jdsika jdsika self-assigned this Jul 11, 2026
@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch from a4b329c to 32821d0 Compare September 11, 2026 12:33
@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch from 32821d0 to 0342283 Compare October 2, 2026 11:48
@jdsika jdsika changed the title fix(generators): add --normalize-prefixes flag for well-known prefix names feat(generators): add --normalize-prefixes flag for well-known prefix names Oct 13, 2026
jdsika added a commit that referenced this pull request Oct 14, 2026
Review update from #4: test_csv_limit left the process-wide CSV field
size limit at 20, so later tests on the same xdist worker that read
prefixmaps' CSV data failed intermittently.

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch from 0342283 to 316b430 Compare October 14, 2026 07:34
jdsika added a commit that referenced this pull request Oct 14, 2026
Review update from #4: test_csv_limit left the process-wide CSV field
size limit at 20, so later tests on the same xdist worker that read
prefixmaps' CSV data failed intermittently.

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch 2 times, most recently from 9ede52d to 29098c8 Compare October 23, 2026 11:48
…mes in every generator

Add an opt-in --normalize-prefixes option that writes the conventional
name of a well-known namespace, e.g. dc for
http://purl.org/dc/elements/1.1/ where a schema declares dce, and writes
the same name in every generator that prints prefix names: gen-owl,
gen-shacl, gen-shex, gen-sparql, gen-yarrrml, gen-sssom and
gen-jsonld-context. Only names change; every IRI stays the same.

The names come from two curated sources, the prefixmaps linked_data
context and the names rdflib binds by default, which take precedence
because rdflib serializes the RDF outputs. prefixmaps' merged context is
not used: it names a namespace after the first registry that lists it,
such as alice for http://example.org/ from prefix.cc.

linkml.generators.common.prefixes computes one plan per schema as a
curies Converter of preferred names with the declared names as
synonyms. A name that the schema or one of its imports declares keeps
denoting its namespace, since LinkML expands the CURIEs of data with all
of them; linkml:types declares schema for http://schema.org/, so a
schema's sdo for https://schema.org/ keeps its name. A prefix also keeps
its name, with a warning, where two namespaces want the same
conventional name or the name is that of a class, slot or permissible
value, which a JSON-LD context uses as a term.

How a generator applies the plan depends on what its prefixes are.
Self-contained documents (Turtle, RDF/XML, ShExC, SPARQL, YARRRML, SSSOM)
use only the preferred names, as a prefix there only abbreviates IRIs
(RDF 1.1 Concepts 1.4); they rename only the prefixes they declare, and
not to a name they write undeclared. A JSON-LD context is shared with the
data written against it, and a compact IRI whose prefix it no longer
defines stops expanding (JSON-LD 1.1 4.1.5), so it keeps every declared
name and adds the preferred one; it writes its own compact IRIs, but not
its keys, with the preferred names, and renames nothing with
--no-prefixes. SchemaLoader-based generators read the plan from a
SchemaView of the schema as given, resolving local imports of YAML text
against base_dir.

gen-prefix-map gives each namespace one name, the schema's, gen-rdf and
gen-jsonld write CURIEs as literal data, and gen-python, gen-pydantic and
gen-namespaces make prefix names part of the generated code, so they do
not offer the option.

test_csv_limit set csv.field_size_limit(20) without restoring it, so any
later test on the same worker that read prefixmaps' CSV data failed; it
now restores the limit.

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
@jdsika
jdsika force-pushed the fix/normalize-well-known-prefixes branch from 29098c8 to d6e7eae Compare October 30, 2026 13:50
@jdsika jdsika changed the title feat(generators): add --normalize-prefixes flag for well-known prefix names feat(generators): add --normalize-prefixes for conventional prefix names in every generator Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant